library(tidyverse)
library(tidytext)
library(tokenizers)9 Синтаксический парсинг
Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.
9.1 Токенизация
Токенизация — процесс разделения текста на составляющие (их называют «токенами»). Токенами могут быть слова, символьные или словесные энграмы (n-grams), то есть сочетания символов или слов, даже предложения или параграфы.
Токенизировать можно в базовом R с использованием регулярных выражений, и Jockers (2014) прекрасно показывает, как это можно делать. Но мы воспользуемся двумя пакетами, которые предназначены специально для работы с текстовыми данными и разделяют идеологию tidyverse: tidytext (Silge и Robinson 2017) и tokenizers (Hvitfeldt и Silge 2022).
Для анализа воспользуемся датасетом c латинским текстом “Записок о Галльской войне”, который мы подготовили в предыдущем уроке. Его можно забрать отсюда.
load("../data/caesar.RData")
caesar <- caesar |>
rename(text = value) |>
select(-link)
caesarФункция unnest_tokens() из пакета tidytext принимает на входе тиббл, название столбца, в котором хранится текст для токенизации, а также название нового столбца, куда будут “сложены” отдельные токены (зачастую это слова, но не обязательно).
unnest_tokens(
tbl,
output,
input,
token = "words",
format = c("text", "man", "latex", "html", "xml"),
to_lower = TRUE,
drop = TRUE,
collapse = NULL,
...
)
Аргумент token принимает следующие значения:
- “words” (default),
- “characters”,
- “character_shingles”,
- “ngrams”,
- “skip_ngrams”,
- “sentences”,
- “lines”,
- “paragraphs”,
- “regex”,
- “ptb” (Penn Treebank).
Используя уже знакомую функцию map, можно запустить unnest_tokens() с разными аргументами:
test <- tibble(text = "Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur. Hi omnes lingua, institutis, legibus inter se differunt.")params <- tribble(
~tbl, ~output, ~input, ~token,
test, "word", "text", "words",
test, "sentence", "text", "sentences",
test, "char", "text", "characters",
)
paramsparams |>
pmap(unnest_tokens) [[1]]
# A tibble: 29 × 1
word
<chr>
1 gallia
2 est
3 omnis
4 divisa
5 in
6 partes
7 tres
8 quarum
9 unam
10 incolunt
# ℹ 19 more rows
[[2]]
# A tibble: 2 × 1
sentence
<chr>
1 gallia est omnis divisa in partes tres, quarum unam incolunt belgae, aliam aq…
2 hi omnes lingua, institutis, legibus inter se differunt.
[[3]]
# A tibble: 166 × 1
char
<chr>
1 g
2 a
3 l
4 l
5 i
6 a
7 e
8 s
9 t
10 o
# ℹ 156 more rows
Следующие значения аргумента token требуют также аргумента n:
params <- tribble(
~tbl, ~output, ~input, ~token, ~n,
test, "ngram", "text", "ngrams", 3,
test, "shingles", "text", "character_shingles", 3
)
params |>
pmap(unnest_tokens) |>
head()[[1]]
# A tibble: 27 × 1
ngram
<chr>
1 gallia est omnis
2 est omnis divisa
3 omnis divisa in
4 divisa in partes
5 in partes tres
6 partes tres quarum
7 tres quarum unam
8 quarum unam incolunt
9 unam incolunt belgae
10 incolunt belgae aliam
# ℹ 17 more rows
[[2]]
# A tibble: 164 × 1
shingles
<chr>
1 gal
2 all
3 lli
4 lia
5 iae
6 aes
7 est
8 sto
9 tom
10 omn
# ℹ 154 more rows
Дальше мы будем работать со словами, поэтому сохраним токенизированный текст “Записок” в виде “опрятного” датасета (одно наблюдение - один ряд).
caesar_tokens <- caesar |>
unnest_tokens("word", "text")
caesar_tokensПри работе с данными в текстовом формате unnest_tokens() опирается на пакет tokenizers, из которого в нашем случае подтягивает функцию tokenize_words. У этой функции есть несколько полезных аргументов: strip_non_alphanum (удаляет пробельные символы и пунктуацию), strip_punct (удаляет пунктуацию), strip_numeric (удаляет числа).
Эти аргументы мы тоже можем задать через unnest_tokens(), поскольку у функции есть аргумент ... (загляните в документацию, чтобы убедиться).
caesar |>
unnest_tokens("word", "text", strip_punct = FALSE)9.2 Лемматизация и частеречная разметка
Лемматизация – приведение слов к начальной форме (лемме). Как правило, она сопровождается частеречной разметкой слов (POS-tagging). В R это умеет делать, например, пакет udpipe (Universal Dependencies Pipeline). Он позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных данных.
Прежде всего нужно выбрать и загрузить модель (список); в нашем случае это модель Perseus, но можно попробовать и другие доступные на сайте https://universaldependencies.org/.
library(udpipe)
# скачиваем модель в рабочую директорию
udpipe_download_model(language = "latin-perseus")
# загружаем модель
latin_perseus <- udpipe_load_model(file = "latin-perseus-ud-2.5-191206.udpipe")
# аннотируем
caesar_annotate <- udpipe_annotate(latin_perseus, caesar$text)Результат возвращается в формате CONLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов. Вот пример разбора предложения:
Cтроки слов содержат следующие поля:
ID: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.FORM: словоформа или знак препинания.LEMMA: Лемма или основа словоформы.UPOSTAG: универсальный тег части речи.XPOSTAG: тег части речи для конкретного языка.FEATS: список морфологических характеристик.HEAD: заголовок текущего токена, который является либо значением ID, либо нулем (0).DEPREL: Universal Stanford dependency relation к (root iff HEAD = 0) или определенному зависящему от языка подтипу.DEPS: Список вторичных зависимостей.MISC: любая другая аннотация.
Для работы данные удобнее трансформировать в прямоугольный формат.
caesar_pos <- as_tibble(caesar_annotate) |>
select(-paragraph_id)
caesar_pos9.3 Обучение модели
Можно заметить, что модель Perseus 2.5 справилась не безупречно: все бельги оказались женского рода, а кельты и вовсе признаны глаголом. Есть ошибки в падежах и числах: например, “provinciae” в четвертом предложении, конечно, не именительный, а родительный падеж. Множество топонимов не опознано в качестве имен собственных.
Здесь есть два пути. Первый: пробовать другие модели, доступные в пакете udpipe. Например, для латыни это PROIEl, обученная не только на классических авторах, но и на Вульгате, или ITTB, обученная на сочинениях Фомы.
Второй путь - обучить модель самостоятельно. Например, для трибанка Perseus доступны более свежие версии (2.13 на момент написания этой главы) на GitHub. Вот некоторые изменения:
- появилась метка
dep_relдля ablativus absolutus (advcl:abs); - исправлены аннотации для супина (
VerbForm=Conv,Aspect=Prosp), а также герундия и герундива (VerbForm=Part,Aspect=Prosp); - добавлен тип для местоимения (
PronType) и вид для глагола (Aspect) и др.
Инструкцию по обучению модели можно найти здесь. По сути трибанк представляет собой коллекцию проверенных вручную CONLL-U файлов, которые передаются нейросети. Следуя этой инструкции и используя трибанк Perseus 2.13, мы обучили новую модель (это заняло около 8 часов на персональном компьютере), которую можно загрузить и использовать для аннотации.
Надо иметь в виду, что само по себе обновление трибанка еще не гарантирует того, что модель будет лучше справляться с парсингом: многое зависит от параметров обучения. В нашем случае, впрочем, некоторые улучшения есть: например, “provinciae” корректно опознано как родительный падеж. Но есть и потери: “fortissimi” в том же предложении выше - это nominativus pluralis, который ошибочно опознан как генитив единственного числа.
latin_perseus_new <- udpipe_load_model("../latin_model/la_perseus-2.13-20231115.udpipe")
caesar_annotate2 <- udpipe_annotate(latin_perseus_new, caesar$text[1])
caesar_pos2 <- as_tibble(caesar_annotate2) |>
select(-paragraph_id)caesar_pos2Пока для наших задач достигнутой точности хватит, но можно попробовать построить нейросеть с более сложной архитектурой. Например, в 2024 г. такая архитектура была предложена и для латинского языка.
9.4 Поле UPOS
Морфологическая аннотация, которую мы получили, дает возможность выбирать и группировать различные части речи. Например, местоимения.
caesar_pos2 |>
filter(upos == "PRON") |>
select(token, lemma, upos, xpos)Посчитать части речи можно так:
upos_counts <- caesar_pos2 |>
group_by(upos) |>
count() |>
arrange(-n)
upos_countsСтолбиковая диаграмма позволяет наглядно представить результаты подсчетов:
upos_counts |>
ggplot(aes(x = reorder(upos, n), y = n, fill = upos)) +
geom_bar(stat = "identity", show.legend = F) +
coord_flip() +
labs(x = NULL) +
theme_bw() 
Отберем наиболее частотные имена и имена собственные.
nouns <- caesar_pos2 |>
filter(upos %in% c("NOUN", "PROPN")) |>
count(lemma) |>
arrange(-n)
nounslibrary(wordcloud)Loading required package: RColorBrewer
library(RColorBrewer)
pal <- RColorBrewer::brewer.pal(8, "Dark2")
wordcloud(nouns$lemma, nouns$n, colors = pal, max.words = 130)
9.5 Поле FEATS
Допустим, нам нужны не все местоимения, а лишь определенные их формы: например, относительные.
rel_pron <- caesar_pos2 |>
filter(str_detect(feats, "PronType=Rel"))
rel_pronПосмотрим на некоторые местоимения в контексте. Для этого добавим html-теги:
highlight_string <- function(idx) str_replace_all(
rel_pron$sentence[idx],
str_glue("(?<= ){rel_pron$token[idx]}(?=\\W)"),
str_glue("<mark>{rel_pron$token[idx]}</mark>"))
highlight_string(1)[1] “Gallia est omnis divisa in partes tres, quarum unam incolunt Belgae, aliam Aquitani, tertiam qui ipsorum lingua Celtae, nostra Galli appellantur.”
highlight_string(13)[1] “In eo itinere persuadet Castico, Catamantaloedis filio, Sequano, cuius pater regnum in Sequanis multos annos obtinuerat et a senatu populi Romani amicus appellatus erat, ut regnum in civitate sua occuparet, quod pater ante habuerit;”
9.6 Поле XPOS
Чтение xpos требует сноровки: например причастие sublata там описывается так: v-srppfb-, где
v= verbum;-на месте лица;s= singularis;r= perfectum (неp, потому чтоp= praesens);p= participium;p= passivum;f= femininum;b= ablativus (неa, потому чтоa= accusativus).
Сравним с описанием личной формы глагола differunt v3ppia---:
v= verbum;3= 3. persona;p= pluralis;p= praesens;i= indicativus;a= activum;--на месте рода и падежа, т.к. форма неличная.
Последнее “место” (Degree) у глаголов всегда свободно; в первой книге там стоит s (superlativus) лишь у florentissimis, что явно ошибка, потому что это не глагол.
Для удобства разобьем xpos на 9 столбцов.
caesar_pos2_sep <- caesar_pos2 |>
separate(xpos, into = c("POS", "xpos"), sep = 1) |>
separate(xpos, into = c("persona", "xpos"), sep = 1) |>
separate(xpos, into = c("numerus", "xpos"), sep = 1) |>
separate(xpos, into = c("tempus", "xpos"), sep = 1) |>
separate(xpos, into = c("modus", "xpos"), sep = 1) |>
separate(xpos, into = c("vox", "xpos"), sep = 1) |>
separate(xpos, into = c("genus", "xpos"), sep = 1) |>
separate(xpos, into = c("casus", "gradus"), sep = 1)
caesar_pos2_sepЭти столбцы тоже можно использовать для поиска конкретных признаков. Посмотрим, например, в каком числе и падеже чаще всего стоит относительное местоимения.
pron_rel_sum <- caesar_pos2_sep |>
filter(upos == "PRON") |>
filter(str_detect(feats, "PronType=Rel")) |>
group_by(numerus, casus) |>
summarise(n = n()) |>
arrange(-n)
pron_rel_sumДля удобства преобразуем сокращения.
pron_rel_sum <- pron_rel_sum |>
filter(casus != "-") |>
mutate(casus = case_when(casus == "n" ~ "nom",
casus == "g" ~ "gen",
casus == "d" ~ "dat",
casus == "a" ~ "acc",
casus == "b" ~ "abl")) |>
mutate(numerus = case_when(numerus == "s" ~ "sing",
numerus == "p" ~ "plur"))
pron_rel_sumФункция facet_wrap позволяет разбить график на две части на основании значения переменной numerus.
pron_rel_sum |>
ggplot(aes(casus, n, fill = casus)) +
geom_bar(stat = "identity", show.legend = FALSE) +
coord_flip() +
theme_light() +
facet_wrap(~numerus) +
labs(x = NULL, y = NULL, title = "Относительные местоимения в BG 1-7")
9.7 Поле DEP_REL
Аналогичным образом можно отбирать синтаксические признаки и их комбинации, а также визуализировать деревья зависимостей для отдельных предложений.
Дерево зависимостей – это направленный граф, который имеет единственную корневую вершину (сказуемое главного предложения) без входящих дуг (рёбер), при этом все остальные вершины имеют ровно одну входящую дугу. Иными словами, каждое слово зависит от другого, но только от одного. Это выглядит примерно так:
library(textplot)
sent <- caesar_pos |>
filter(doc_id == "doc1", sentence_id == 10)
sent |>
distinct(sentence) |>
pull(sentence) [1] "Apud Helvetios longe nobilissimus fuit et ditissimus Orgetorix."
textplot_dependencyparser(sent, size = 3)
Можно поспорить с тем, что nobilissiumus и ditissimus - это глаголы, хотя модель Perseus 2.5 верно опознала их в качестве именной части сказуемого при подлежащем “Оргеториг”. Информация, которая на графе представлена стрелками, хранится в таблице в полях token_id и head_token_id и dep_rel. Корневой токен всегда имеет значение 0, то есть ни от чего не зависит.
sent |>
select(token_id, token, head_token_id, dep_rel)Правила синтаксической разметки для латинского языка доступны по ссылке, а расшифровку сокращений (для всех языков) надо смотреть здесь.
Вообще латынь (как и древнегреческий ) – не очень ресурсный язык; для многих языков доступны хорошие предобученные модели.
9.8 Совместная встречаемость слов
Функция cooccurence() из пакета udpipe позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:
- слова встречаются в одном и том же документе/предложении/параграфе;
- слова следуют за другим словом;
- слова находятся по соседству с другим словом на расстоянии n слов.
Код ниже позволяет выяснить, какие существительные встречаются в одном предложении:
caesar_subset <- subset(caesar_pos2, upos == "NOUN")
cooc <- cooccurrence(caesar_subset, term = "lemma", group = c("doc_id", "sentence_id")) |> as_tibble() |>
filter(cooc > 25)
coocЭтот результат легко визуализировать, используя пакет ggraph (подробнее о нем мы будем говорить в следующих уроках):
library(igraph)
library(ggraph)
wordnetwork <- graph_from_data_frame(cooc)
ggraph(wordnetwork, layout = "fr") +
geom_edge_link(aes(width = cooc), alpha = 0.8, edge_colour = "grey90", show.legend=FALSE) +
geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
theme_void() +
labs(title = "Совместная встречаемость существительных", subtitle = "De Bello Gallico 1-7")
Чтобы узнать, какие слова чаще стоят рядом, используем ту же функцию, но с другими аргументами:
cooc2 <- cooccurrence(caesar_subset$lemma, relevant = caesar_subset$upos %in% c("NOUN", "ADJ"), skipgram = 1) |>
as_tibble() |>
filter(cooc > 10)
cooc2wordnetwork <- graph_from_data_frame(cooc2)
ggraph(wordnetwork, layout = "fr") +
geom_edge_link(aes(width = cooc), edge_colour = "grey90", edge_alpha=0.8, show.legend = F) +
geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
labs(title = "Слова, стоящие рядом в тексте", subtitle = "De Bello Gallico 1-7") +
theme_void()